Repository Public MIT
1import Link from "next/link";2import { notFound } from "next/navigation";3import { LIMITS } from "@skills/registry-core";4import { ActionForm, Field } from "@/components/action-form";5import { StateBadge } from "@/components/badges";6import { Uploader } from "@/components/uploader";7import { bytes, dateTime } from "@/lib/format";8import { requireSession } from "@/lib/session";9import { canWriteNamespace, getNamespace } from "@/server/auth";10import { csrfTokenFor } from "@/server/crypto";11import { ApiError } from "@/server/errors";12import { getArtifactById, listReleases } from "@/server/registry";13import { artifactStateAction, releaseNotesAction, revokeReleaseAction, updateArtifactAction } from "../../actions";1415export const metadata = { title: "Manage artifact" };16const PW_HINT = "Needed only if you signed in more than 15 minutes ago.";1718export default async function Page({ params }: { params: Promise<{ id: string }> }) {19 const { ctx, principal } = await requireSession();20 let a;21 try {22 a = await getArtifactById(ctx, (await params).id);23 } catch (e) {24 if (e instanceof ApiError) notFound();25 throw e;26 }27 const ns = await getNamespace(ctx, a.namespace);28 if (!ns || !(await canWriteNamespace(ctx, ns))) notFound();29 const releases = await listReleases(ctx, a);30 const uploads = await ctx.sql<{ id: string; version: string; state: string; created_at: Date; rejection: { findings: { code: string; message: string; blocking: boolean }[] } | null }[]>`31 SELECT id, version, state, created_at, rejection FROM uploads WHERE artifact_id = ${a.id} AND state IN ('rejected','quarantined') ORDER BY created_at DESC LIMIT 5`;32 const closed = a.state === "archived" || a.state === "taken_down";3334 return (35 <div className="space-y-10">36 <header>37 <nav aria-label="Breadcrumb" className="mb-3 font-mono text-[12px] text-faint"><Link href="/dashboard/artifacts" className="hover:text-muted">artifacts</Link> / manage</nav>38 <h1 className="break-words font-mono text-2xl font-medium tracking-tight">@{a.namespace}/{a.slug} <StateBadge state={a.state} reason={a.deprecation_reason} /></h1>39 <p className="mt-2 text-sm text-muted">{a.type} · {a.visibility} · <Link className="link" href={`/@${a.namespace}/${a.slug}`}>public page</Link></p>40 </header>4142 <section aria-labelledby="up-h">43 <h2 id="up-h" className="mb-3 text-base font-semibold">Publish a release</h2>44 {closed ? <p className="card p-5 text-sm text-muted">This artifact is {a.state.replace("_", " ")} and accepts no new releases.</p> : <Uploader artifactId={a.id} csrf={csrfTokenFor(principal.csrfSecret)} maxBytes={LIMITS.maxArchiveBytes} />}45 {uploads.length > 0 && (46 <ul className="mt-4 space-y-2 text-sm">47 {uploads.map((u) => (48 <li key={u.id} className="rounded-lg border border-line p-3">49 <p className="font-mono text-[12px]"><span className={u.state === "rejected" ? "text-danger" : "text-warn"}>{u.state}</span> · v{u.version} · {dateTime(u.created_at)}</p>50 {u.rejection?.findings.filter((f) => f.blocking).slice(0, 6).map((f, i) => <p key={i} className="mt-1 text-muted">{f.message}</p>)}51 {u.state === "quarantined" && <p className="mt-1 text-muted">Waiting for a moderator's security review.</p>}52 </li>53 ))}54 </ul>55 )}56 </section>5758 <section aria-labelledby="rel-h">59 <h2 id="rel-h" className="mb-3 text-base font-semibold">Releases</h2>60 {releases.length === 0 ? <p className="text-sm text-muted">No releases yet.</p> : (61 <ul className="space-y-3">62 {releases.map((r) => (63 <li key={r.id} className="card p-5">64 <p className="flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-sm">65 <span className="text-text">v{r.version}</span> <StateBadge state={r.state} reason={r.revocation_reason} />66 <span className="text-[11px] text-faint">{dateTime(r.published_at)} · {bytes(Number(r.size_bytes))} · sha256 {r.sha256.slice(0, 16)}…</span>67 </p>68 {r.state === "revoked" ? <p className="mt-2 text-sm text-muted">Revoked: {r.revocation_reason}</p> : (69 <div className="mt-4 grid gap-6 lg:grid-cols-2">70 <ActionForm action={releaseNotesAction} submit="Save notes">71 <input type="hidden" name="releaseId" value={r.id} />72 <label htmlFor={`notes-${r.id}`} className="label">Release notes</label>73 <textarea id={`notes-${r.id}`} name="releaseNotes" rows={3} defaultValue={r.release_notes ?? ""} maxLength={20000} className="input py-2" />74 <p className="mt-1.5 text-xs text-muted">Notes are the only editable part of a release.</p>75 </ActionForm>76 <details>77 <summary className="cursor-pointer text-sm text-danger">Revoke this version…</summary>78 <ActionForm action={revokeReleaseAction} submit="Revoke release" danger className="mt-3 space-y-3" confirm={`Revoke v${r.version}? Downloads will be blocked permanently.`}>79 <input type="hidden" name="releaseId" value={r.id} />80 <Field label="Public reason" name="reason" required minLength={5} maxLength={1000} placeholder="e.g. Contains a leaked credential" />81 <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> I understand revocation cannot be undone.</label>82 <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />83 </ActionForm>84 </details>85 </div>86 )}87 </li>88 ))}89 </ul>90 )}91 </section>9293 <section aria-labelledby="meta-h" className="max-w-2xl">94 <h2 id="meta-h" className="mb-3 text-base font-semibold">Listing metadata</h2>95 <ActionForm action={updateArtifactAction} submit="Save metadata" className="card space-y-4 p-6">96 <input type="hidden" name="id" value={a.id} />97 <input type="hidden" name="previousVisibility" value={a.visibility} />98 <Field label="Summary" name="summary" required minLength={10} maxLength={300} defaultValue={a.summary} hint="Replaced by the manifest description when a newer release becomes latest." />99 <Field label="Source repository" name="repository" type="url" defaultValue={a.repository_url ?? ""} hint="Changing it clears any source verification." />100 <div className="grid gap-4 sm:grid-cols-2">101 <Field label="Homepage" name="homepage" type="url" defaultValue={a.homepage_url ?? ""} />102 <Field label="Issue tracker" name="issues" type="url" defaultValue={a.issues_url ?? ""} />103 </div>104 <Field label="Keywords" name="keywords" defaultValue={a.keywords.join(", ")} hint="Up to 12, comma-separated, lowercase." />105 <Field label="Visibility" name="visibility"><select id="f-visibility" name="visibility" defaultValue={a.visibility} className="input"><option value="private">Private</option><option value="unlisted">Unlisted (link only)</option><option value="public">Public</option></select></Field>106 <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirmPublic" className="mt-1" /> I confirm the change of visibility (required when exposing the artifact).</label>107 <Field label="Password" name="password" type="password" autoComplete="current-password" hint={`For visibility changes. ${PW_HINT}`} />108 </ActionForm>109 </section>110111 <section aria-labelledby="life-h" className="max-w-2xl">112 <h2 id="life-h" className="mb-3 text-base font-semibold">Lifecycle</h2>113 <div className="card space-y-6 p-6">114 {a.state === "deprecated" ? (115 <ActionForm action={artifactStateAction} submit="Remove deprecation">116 <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="undeprecate" />117 <p className="text-sm text-muted">Deprecated: {a.deprecation_reason}</p>118 </ActionForm>119 ) : !closed && (120 <ActionForm action={artifactStateAction} submit="Deprecate" danger className="space-y-3">121 <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="deprecate" />122 <Field label="Deprecation reason (public)" name="reason" required minLength={5} maxLength={1000} placeholder="Use @namespace/other instead" />123 <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> Show a deprecation warning to everyone.</label>124 <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />125 </ActionForm>126 )}127 {!closed && (128 <details>129 <summary className="cursor-pointer text-sm text-danger">Archive this artifact…</summary>130 <ActionForm action={artifactStateAction} submit="Archive" danger className="mt-3 space-y-3" confirm="Archive this artifact? It becomes read-only and leaves search results.">131 <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="archive" />132 <Field label="Reason" name="reason" maxLength={1000} />133 <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> Archive: no new releases, hidden from listings. Existing releases stay downloadable by direct link.</label>134 <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />135 </ActionForm>136 </details>137 )}138 {closed && <p className="text-sm text-muted">This artifact is {a.state.replace("_", " ")}.</p>}139 </div>140 </section>141 </div>142 );143}144